Docker 基础(四)- Compose

Compose 的简介和安装

文档参考快速入门安装综述Linux 安装文档

在单容器时代,我们使用 docker run 配合各种参数就能玩得风生水起。但随着业务演进到微服务时代,问题接踵而至:一个标准的电商系统可能需要 1 个 Spring Boot 业务容器、1 个 Redis 缓存、1 个 MySQL 数据库、1 个 RabbitMQ 消息队列。如果依然用普通的 Docker 命令,你必须手动敲 4 次极其冗长的 docker run,还要小心翼翼地处理它们的启动顺序、手动创建网络并把它们一个个拉进来。一旦漏掉一个参数,整个系统就会瘫痪。为了解决单机多容器分布式应用的编排与部署痛点,生产的标配工具就是 Docker Compose


什么是 Compose

Compose 最核心的哲学就是 声明式基础设施即代码 (IaC)。它允许你通过一个单独的 docker-compose.yml 配置文件,把组成应用的所有容器服务、网络、数据卷全部声明式地下定义。随后,你只需要在终端轻飘飘地敲下一行 docker compose up,整个微服务群落就会像乐高积木一样,自动、有序、完美地在后台拼装并运行起来。

Compose 和 Spring 框架都极力反对命令式的硬编码,推崇声明式的配置。你只需要声明你想要达到的最终状态(What to do),而不需要关心中间具体怎么创建的细节(How to do),全部交由底层的“容器”去反转控制。Spring 是代码维度的 IoC 容器,而 Docker Compose 是容器维度的 IoC 容器。它们一个在软件内部负责对象的编排,一个在软件外部负责集装箱的编排,两者核心思想完全一脉相承,都是为了让分布式系统解耦、自动化和具备极高的弹性。

  • 控制反转与声明式管理
    • 在没有 Spring 之前,你想用一个对象,必须自己手动 new UserService(),并手动把 UserDao 塞进去(命令式)。 有了 Spring,你只需要在类上打一个 @Service 或 @Component 标签。Spring 容器启动时,会自动把这些 Bean 实例化并组装好。
    • 在没有 Compose 之前,你需要自己手动敲 docker run -d –name redis …,一步步去肉身构建容器(命令式)。 有了 Compose,你只需要在 docker-compose.yml 里面声明你需要什么服务(Services)。Compose 容器启动时,会自动把这些镜像拉取、创建并运行起来。
  • 依赖注入与服务依赖
    • 当 UserService 依赖 UserDao 时,你只需要写上 @Autowired,Spring 自动会把依赖的对象注入进来。如果某个 Bean 必须在另一个 Bean 之后初始化,还可以用 @DependsOn 注解来强制控制顺序。
    • 当你的 Java 容器依赖 Redis 容器时,你在 docker-compose.yml 中写上 depends_on: - my-redis。Compose 就会确保先启动 Redis,等它就绪后,再启动 Java 容器。这在本质上就是基础设施层面的依赖注入与顺序控制。
  • 服务发现与内部通信
    • 在 Spring 容器内部,所有的 Bean 都注册在 ApplicationContext 中。你想用谁,直接通过 beanName(比如 userServiceImpl)就能精准找到它并调用其方法,不需要知道这个对象在内存的什么十六进制地址上。
    • Compose 启动时会默认创建一个专属的内部虚拟网络。所有加入的服务都可以直接通过服务名( 如 http://my-redis:6379 )进行跨容器通信。Compose 内置了 DNS 服务器,会自动把服务名翻译成动态变动的内部 IP,完全不需要硬编码 IP 地址。
  • 模块化与配置复用
    • 通过 @Configuration 和 @Bean,你可以把数据源、安全框架、缓存切面做成一个个独立的配置模块。想用哪个,引入依赖、写个配置就能无缝拼装。
    • 通过 docker-compose.yml,把 MySQL、Redis、Nginx、Java微服务定义成一个个独立的服务节点,配合 environment 传递环境变量,支持 “一套模版,到处运行(开发/测试/生产一键切换)”。


核心概念:服务、网络、卷

在写 docker-compose.yml 之前,必须分清它的三个一等公民:

  • 服务 (Services):一个服务在本质上就代表了一个准备运行的容器实例(如 nginx、mysql、web应用)。你可以在服务里指定它用什么镜像、映射什么端口、挂载什么目录。
  • 网络 (Networks):Compose 会在背后自动创建一个专属的自定义网桥网络。所有写在同一个 docker-compose.yml 里的服务默认都会自动加入这个网络,天然支持通过服务名进行内部动态 DNS 域名解析。
  • 卷 (Volumes):用于定义跨容器生命周期的数据持久化卷,确保数据库等容器死掉后数据不丢失。


实际经典案例

docker-compose.yml 准备

cd ~/app_user && vim docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# 这里的 version 指的是 Compose 文件格式规范的版本。
# 自从 Docker 官方全面升级到 Compose V2 之后,官方已经彻底废弃了文件头部的 version 声明。
# 当你删掉 version 之后,它会默认直接采用当前 Docker 引擎所能支持的最高、最新特性版本来解析整个编排文件。
# version: '3'

# 网络声明
networks:
backend-net:
driver: bridge
name: backend-net # 显式指定宿主机层面的真实网络名称,这样生成的网络名就不会带目录前缀了。

# 命名数据卷声明,在 Linux 中命名卷默认都是在宿主机的以下绝对路径下创建的:/var/lib/docker/volumes/
# 可以使用命令查询:
# 1. 先列出当前宿主机上所有的卷 docker volume ls
# 2. 精准透视这个卷的详细底层档案 docker volume inspect [你的项目的目录名]_my_mysql_data
volumes:
my_mysql_data:
my_redis_data:

services:
# 1. MySQL 数据库服务节点
my_mysql: # 这是服务名称,随便你怎么叫
image: mysql:8.4.9-oraclelinux9 # 建议在生产中锁定具体大版本
container_name: my_mysql_3300
restart: always
environment:
MYSQL_ROOT_PASSWORD: "123456"
MYSQL_ALLOW_EMPTY_PASSWORD: 'no'
MYSQL_DATABASE: 'db03'
MYSQL_USER: owlias
MYSQL_PASSWORD: '123456'
TZ: Asia/Shanghai
ports:
- "3300:3306"
privileged: true # 对应 --privileged=true
volumes:
- my_mysql_data:/var/lib/mysql
- /opt/apps/my_mysql/conf:/etc/mysql/conf.d
- /opt/apps/my_mysql/log:/var/log
# 映射初始化的SQL脚本,生产规范:一定要用数字前缀强行锁定顺序,01_schema.sql (负责创建数据库、表结构),02_data.sql (负责灌入初始化的基础数据/字典数据)
- /opt/apps/my_mysql/init-sql:/docker-entrypoint-initdb.d
# 通过 command 选项将参数无缝注入 mysqld 启动进程,解决远程不能访问的问题
command: --mysql-native-password=ON
networks:
- backend-net
logging:
driver: "json-file"
options:
max-size: "50m" # 限制日志大小,防止撑爆磁盘
max-file: "3"
healthcheck: # 生产健康检查:确保端口真正可用
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p123456"]
interval: 10s
timeout: 10s
retries: 10
start_period: 30s # 给初始化留出 buffer 时间

# 2. Redis 缓存服务节点
my_redis: # 这是服务名称,随便你怎么叫
image: redis:6.0.8
container_name: my_redis_6370
restart: always
ports:
- "6370:6379"
volumes:
- my_redis_data:/data
- /opt/apps/my_redis/conf/redis.conf:/etc/redis/redis.conf
networks:
- backend-net
command: redis-server /etc/redis/redis.conf
environment:
- TZ=Asia/Shanghai
logging:
driver: "json-file"
options:
max-size: "20m"
max-file: "3"
healthcheck: # 确保 Redis 已经就绪
test: ["CMD", "redis-cli", "-a", "123456", "ping"]
interval: 10s
timeout: 5s
retries: 5

# 3. Spring Boot 应用服务节点
app_user: # 这是服务名称,随便你怎么叫
image: app_user:v1.0 # 你的应用镜像名称
container_name: app_user_8081
restart: always
ports:
- "8081:8081"
volumes:
# 应用jar包数据卷:存放jar包,这里的名称为 app.jar
- /opt/apps/app_user/app:/app
# 应用日志数据卷:存放logback日志文件
- /opt/apps/app_user/logs:/data/logs
environment:
- TZ=Asia/Shanghai
# 只需要这一行,强制告诉 Jar 包在容器内运行时启动 prod 环境配置!
- SPRING_PROFILES_ACTIVE=prod
- JAVA_OPTS=-Xms512m -Xmx512m -XX:+UseG1GC
networks:
- backend-net
depends_on: # 严格时序控制:只有当 mysql 和 redis 健康检查通过后,才允许拉起应用容器
my_mysql:
condition: service_healthy
my_redis:
condition: service_healthy
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"

检查 docker-compose.yml 的语法

1
2
# 如果没有任何输出,则表示语法没问题
$ docker compose config -q


WEB应用镜像的准备

准备 app_user:v1.0 的镜像: 将以下内容写入到对应目录的 Dockerfile 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 基础镜像:这个镜像是我们事先准备好的
FROM my-centos7-java17-dev:v1.0

# 镜像维护者标签
LABEL maintainer="owlias"

# 1. 强行锁定容器内部时区为上海(防止 Java 的 log 以及数据库时间出现 8 小时断层差)
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

# 2. 创建应用工作主目录
WORKDIR /app

# 3. 将同级目录下的 Jar 包物理拷贝到容器内部,并重命名为更规范的 app.jar
COPY docker-app-user.jar /app/app.jar

# 4. 声明容器运行时开辟的内部数据挂载点(完美对接 Compose 里的 /opt/apps/app_user)
VOLUME /data/logs

# 5. 暴露你的微服务核心端口
EXPOSE 8081

# 6. 核心启动指挥官:
# 使用 exec 格式的 ENTRYPOINT,配合 Compose 传进来的 $JAVA_OPTS 环境变量
# 这样既能动态吃掉你在 Compose 里分配的 -Xms512m 内存,又能保证 Java 进程作为 PID 1 响应系统的平滑优雅停机指令(SIGTERM)
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]

构建镜像:

1
2
$ docker build -t app_user:v1.0 .
$ docker images


数据卷以及配置准备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# my_mysql_3300 容器环境准备
$ mkdir -p /opt/apps/my_mysql/{conf,init-sql}

$ cat <<EOF > /opt/apps/my_mysql/conf/my.cnf
[client]
default_character_set=utf8

[mysqld]
collation_server=utf8_general_ci
character_set_server=utf8
EOF

# my_mysql_3300 初始化需要执行的 SQL
# 给 EOF 加上单引号,完美隔绝 Bash 的符号解析
$ cat <<'EOF' > /opt/apps/my_mysql/init-sql/01_schema.sql
-- 1. 允许 root 账号从任何远程 IP (%) 登录,并锁定密码和传统认证插件
ALTER USER 'root'@'%' IDENTIFIED WITH 'mysql_native_password' BY '123456';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;

-- 2. 顺手兜底:激活你在 YML 里创建的业务账号 owlias,确保它也能远程连接并拥有 db03 的完全控制权
ALTER USER 'owlias'@'%' IDENTIFIED WITH 'mysql_native_password' BY '123456';
GRANT ALL PRIVILEGES ON db03.* TO 'owlias'@'%';
FLUSH PRIVILEGES;

-- 3. 创建数据库
CREATE DATABASE IF NOT EXISTS db03;
USE db03;

-- 4. 创建表
CREATE TABLE IF NOT EXISTS `t_user` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '用户名',
`password` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '密码',
`sex` TINYINT(4) NOT NULL DEFAULT '0' COMMENT '性别 0=女 1=男 ',
`deleted` TINYINT(4) UNSIGNED NOT NULL DEFAULT '0' COMMENT '删除标志,默认0不删除,1删除',
`update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`create_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=1114 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
EOF


# my_redis_6370 容器环境准备
$ mkdir -p /opt/apps/my_redis/conf

$ cat <<EOF > /opt/apps/my_redis/conf/redis.conf
# 允许任意 IP 连接访问
bind 0.0.0.0
# 非守护模式启动
daemonize no
# 关闭保护模式,允许外部网络访问
protected-mode no
# 开启 AOF 持久化(强力数据保护)
appendonly yes
# 设置你的强 Redis 访问密码
requirepass 123456
EOF


# app_user_8081 容器环境准备
$ mkdir -p /opt/apps/app_user
$ cp /root/docker-app-user.jar /opt/apps/app_user/app/app.jar


执行 docker-compose.yml

1
$ docker compose up -d

如果存在上一次启动失败了,比如本地的命名数据卷 my_mysql_data 里面已经是一堆残缺不全的死锁系统文件了。如果不清空,直接再次 up 依然会报错。请在终端果断敲入以下命令,将失败的容器与污染的卷连根拔起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$ docker logs my_mysql_3300

# 1. 彻底停止并移除当前残留的容器,同时加上 -v 无情粉碎损坏的物理卷
$ docker compose down -v

# 2. 【双重保险】稳妥起见,手动清理一下宿主机挂载出的 log 目录下的残余(防止旧日志锁死)
$ rm -rf /opt/apps/my_mysql/log/*
$ rm -rf /opt/apps/app_user/logs

# 3. 重新一键轰鸣升空!
$ docker compose up -d
[+] up 6/6
✔ Network backend-net Created 0.2s
✔ Volume app_user_my_mysql_data Created 0.0s
✔ Volume app_user_my_redis_data Created 0.0s
✔ Container my_mysql_3300 Healthy 11.8s
✔ Container my_redis_6370 Healthy 11.8s
✔ Container app_user_8081 Started 12.7s

# 查看这组 compose 的健康度、端口映射和运行状态
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
app_user_8081 app_user:v1.0 "sh -c 'java $JAVA_O…" app_user About a minute ago Up About a minute 0.0.0.0:8081->8081/tcp, [::]:8081->8081/tcp
my_mysql_3300 mysql:8.4.9-oraclelinux9 "docker-entrypoint.s…" my_mysql About a minute ago Up About a minute (healthy) 33060/tcp, 0.0.0.0:3300->3306/tcp, [::]:3300->3306/tcp
my_redis_6370 redis:6.0.8 "docker-entrypoint.s…" my_redis About a minute ago Up About a minute (healthy) 0.0.0.0:6370->6379/tcp, [::]:6370->6379/tcp


常用指令快查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 前台阻塞启动。在前台启动并实时打印所有容器的聚合日志。一旦你在终端按下 Ctrl + C,所有容器就会跟着一起死掉(退出)
# 适用于刚写完新配置,想盯着控制台看有没有起步报错或者断层异常。
$ docker compose up

# 最常用:后台静默启动
# 在后台启动所有在 docker-compose.yml 中定义的容器。如果镜像不存在,它会自动下载;如果配置有变动,它会增量更新对应的容器。
$ docker compose up -d

# 不仅启动容器,而且在启动前强制重新构建你的自定义 Dockerfile 镜像(比如你的 app_user_8081)。它会跳过 Docker 的构建缓存。
$ docker compose up -d --build

# 温和休眠:只停进程,留活身体。向容器内的进程发送 SIGTERM 信号,让其优雅停机,然后容器进入 Exited 状态。
# 它不会删除容器,不会销毁虚拟网络,更不会动数据。当你再次输入 docker compose start 时,它们能以原班人马瞬间复活。
$ docker compose stop

# 不仅停止容器,还会把容器物理删除,同时顺手注销掉由 Compose 创建的虚拟网桥(如 backend-net)。
# 只要你配置了物理挂载(如 /opt/apps/my_mysql/data),你的业务数据和数据库数据绝对不会丢!但下一次启动时,Docker 会为你重新建一套全新的容器皮肤。
$ docker compose down

# 彻底粉碎缓存!连同 Docker 自动托管的匿名数据卷(Volume)也一并铲除(排查系统表损坏的绝招)
$ docker compose down -v


# 局部彻底粉碎并重新拉起某一个服务
$ docker compose stop my_redis_6370
$ docker compose rm -f my_redis_6370
$ docker compose up -d my_redis_6370

# 随时随地查看当前这一组民工容器的健康度、端口映射和运行状态
$ docker compose ps
# 像 tail -f 一样流式追踪日志(支持按服务过滤)
$ docker compose logs -f app_user_8081


附 app-user 应用源码

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>com.demo.user</groupId>
<artifactId>docker-app-user</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
<java.version>17</java.version>
<mybatis.version>3.0.3</mybatis.version>
<springdoc.version>2.6.0</springdoc.version>
<lombok.version>1.18.30</lombok.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>

<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
</dependencies>

<build>
<finalName>docker-app-user</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>


日志配置

src/main/resources/logback-spring.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds">
<!--scan=true:改动 logback-spring.xml 无需重启项目,日志策略会立刻生效-->
<!--日志输出到 /data/logs,对应数据卷挂载 -v /opt/apps/app-user/logs:/data/logs-->
<property name="LOG_PATH" value="/data/logs"/>
<property name="CONSOLE_LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %magenta(${PID:- }) --- [%15.15thread] %cyan(%-40.40logger{39}) : %m%n"/>
<property name="FILE_LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level ${PID:- } --- [%thread] %logger{50} - [%method,%line] - %m%n"/>

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>

<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/sys-info.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archive/sys-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>30GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>DENY</onMatch>
<onMismatch>ACCEPT</onMismatch>
</filter>
</appender>

<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/sys-error.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archive/sys-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
</appender>

<!--所有环境的公共配置-->
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="INFO_FILE" />
<appender-ref ref="ERROR_FILE" />
</root>
</configuration>


应用配置文件

src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server:
port: 8081

spring:
application:
name: docker-app-user
profiles:
# 核心开关:本地开发时写 dev;打包发布到生产 Docker 环境时写 prod
active: prod

# MyBatis 配置
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.demo.user.model.entity
configuration:
map-underscore-to-camel-case: true # 开启下划线自动转驼峰

src/main/resources/application-dev.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
spring:
# 数据库连接配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.1.8:3306/db03?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 123456
# Redis 配置
data:
redis:
host: 192.168.1.8
port: 6379
password: 123456
timeout: 5000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0

# Springdoc Swagger 配置
# 默认页面访问地址:http://localhost:8081/swagger-ui/index.html
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
operations-sorter: alpha

src/main/resources/application-prod.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
spring:
# 数据库连接配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
# 容器内部的真实端口,因为 Spring Boot 和 MySQL 在同一个 Docker 网络里手拉手通信,它不需要走宿主机的 3300 转发!
url: jdbc:mysql://my_mysql_3300:3306/db03?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: owlias
password: 123456
# Redis 配置
data:
redis:
host: my_redis_6370
port: 6379 # 容器内部的真实端口!因为 Spring Boot 和 Redis 在同一个 Docker 网络里手拉手通信,它不需要走宿主机的 6370 转发!
password: 123456
timeout: 5000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0

# Springdoc Swagger 配置
# 默认页面访问地址:http://localhost:8081/swagger-ui/index.html
springdoc:
api-docs:
enabled: false
path: /v3/api-docs
swagger-ui:
enabled: false
operations-sorter: alpha


启动类

1
2
3
4
5
6
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}


配置类

RedisConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.demo.user.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);

// 下面的代码会在生成的JSON串中加入危险的 @class 项,{\"@class\":\"com.demo.user.model.dto.UserDTO\",\"id\":1116,..}
// 当 UserDTO 的包名改变以后,redis 缓存数据全部不可用!
/*StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
template.setKeySerializer(stringRedisSerializer);
template.setHashKeySerializer(stringRedisSerializer);
// 使用 JSON 序列化 Value,防止 Redis 客户端查看时出现乱码
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(jsonRedisSerializer);
template.setHashValueSerializer(jsonRedisSerializer);*/

// 💡 适配 Spring Boot 3 规范:使用其推荐的更安全的 Builder 模式构建序列化器
// 使用定制的 ObjectMapper 构建序列化器
Jackson2JsonRedisSerializer<Object> jsonSerializer = new Jackson2JsonRedisSerializer<>(OBJECT_MAPPER, Object.class);
// 规整锁死 RedisTemplate 的序列化规则
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
template.setKeySerializer(stringRedisSerializer);
template.setHashKeySerializer(stringRedisSerializer);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);

template.afterPropertiesSet();
return template;
}
}

SwaggerConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.demo.user.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class SwaggerConfig implements WebMvcConfigurer {
@Bean
public OpenAPI userMicroserviceOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("用户中心微服务 API 文档")
.description("对外暴露的用户新增与主键查询微服务接口")
.version("v1.0"));
}
}


model 类

Result

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.demo.user.model.common;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;

@Data
@Schema(description = "全局统一响应包装对象")
public class Result<T> implements Serializable {

@Schema(description = "状态码 (200成功,其他失败)")
private int code;

@Schema(description = "提示信息/错误描述")
private String message;

@Schema(description = "承载的业务数据")
private T data;

@Schema(description = "错误追踪码 (失败时用于前后端日志对齐排查)")
private String traceId;

// 快捷成功返回
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("success");
result.setData(data);
return result;
}

// 快捷失败返回
public static <T> Result<T> fail(int code, String message, String traceId) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
result.setTraceId(traceId);
return result;
}
}

ResultCode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.demo.user.model.common;

import lombok.Getter;

@Getter
public enum ResultCode {
SUCCESS(200, "操作成功"),
PARAM_ERROR(400, "参数校验未通过"),
UNAUTHORIZED(401, "暂未登录或Token失效"),
FORBIDDEN(403, "没有相关操作权限"),
NOT_FOUND(404, "请求的资源不存在"),

// 业务专属错误码(5000 ~ 5999)
USER_NOT_EXIST(5001, "该用户在系统内不存在"),
USER_ALREADY_EXIST(5002, "用户名已被占用"),

SYSTEM_ERROR(500, "系统内部服务器大崩溃,请联系运维排查");

private final int code;
private final String message;

ResultCode(int code, String message) {
this.code = code;
this.message = message;
}
}

BizException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.demo.user.model.ex;

import com.demo.user.model.common.ResultCode;
import lombok.Getter;

@Getter
public class BizException extends RuntimeException {
private final int code;

public BizException(ResultCode resultCode) {
super(resultCode.getMessage());
this.code = resultCode.getCode();
}

public BizException(int code, String message) {
super(message);
this.code = code;
}
}

User

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.demo.user.model.entity;

import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;

@Data
public class User implements Serializable {
private Integer id;
private String username;
private String password;
private Integer sex;
private Integer deleted;
private LocalDateTime updateTime;
private LocalDateTime createTime;
}

UserDTO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.demo.user.model.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.hibernate.validator.constraints.Range;

@Data
@Schema(description = "用户业务传输对象")
public class UserDTO {
@Schema(description = "用户ID (新增时为空)")
private Integer id;

@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "用户名不能为空")
@Size(min = 4, max = 20, message = "用户名长度必须在 4 到 20 个字符之间")
private String username;

@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 30, message = "密码长度必须在 6 到 30 个字符之间")
private String password;

@Schema(description = "性别 0=女 1=男", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "性别不能为空")
@Range(min = 0, max = 1, message = "性别输入非法,0代表女,1代表男")
private Integer sex;
}

UserConvert

1
2
3
4
5
6
7
8
9
10
11
package com.demo.user.model.convert;

import com.demo.user.model.dto.UserDTO;
import com.demo.user.model.entity.User;
import org.mapstruct.Mapper;

@Mapper(componentModel = "spring") // 让 Spring 管理该实例,支持 @Autowired 注入
public interface UserConvert {
User toEntity(UserDTO dto);
UserDTO toDTO(User user);
}


mapper dao

UserMapper

1
2
3
4
5
6
7
8
9
10
11
package com.demo.user.mapper;

import com.demo.user.model.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface UserMapper {
int insertUser(User user);
User selectById(@Param("id") Integer id);
}

src/main/resources/mapper/UserMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.demo.user.mapper.UserMapper">
<insert id="insertUser" useGeneratedKeys="true" keyProperty="id">
INSERT INTO t_user (username, password, sex, deleted)
VALUES (#{username}, #{password}, #{sex}, 0)
</insert>

<select id="selectById" resultType="com.demo.user.model.entity.User">
SELECT id, username, password, sex, deleted, update_time, create_time
FROM t_user
WHERE id = #{id} AND deleted = 0
</select>
</mapper>


service

UserService

1
2
3
4
5
6
7
8
package com.demo.user.service;

import com.demo.user.model.dto.UserDTO;

public interface UserService {
UserDTO addUser(UserDTO userDTO);
UserDTO findUserById(Integer id);
}

UserServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
package com.demo.user.service.impl;

import com.demo.user.config.RedisConfig;
import com.demo.user.mapper.UserMapper;
import com.demo.user.model.common.ResultCode;
import com.demo.user.model.convert.UserConvert;
import com.demo.user.model.dto.UserDTO;
import com.demo.user.model.entity.User;
import com.demo.user.model.ex.BizException;
import com.demo.user.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
public class UserServiceImpl implements UserService {

private static final String CACHE_KEY_PREFIX = "user:cache:id:";
private static final long CACHE_TTL = 60; // 缓存失效时间:60分钟

@Resource
private UserMapper userMapper;

@Resource
private UserConvert userConvert;

@Resource
private RedisTemplate<String, Object> redisTemplate;

@Override
@Transactional(rollbackFor = Exception.class) // 涉及数据库写入,必须开启事务
public UserDTO addUser(UserDTO userDTO) {
User user = userConvert.toEntity(userDTO);
userMapper.insertUser(user);
UserDTO resultDTO = userConvert.toDTO(user);

// 双写策略:同步写入 Redis 缓存,并设置 60 分钟过期时间(防止冷数据无限堆积内存)
String cacheKey = CACHE_KEY_PREFIX + resultDTO.getId();
redisTemplate.opsForValue().set(cacheKey, resultDTO, CACHE_TTL, TimeUnit.MINUTES);
log.info("用户数据成功写入 DB 与 Redis 缓存,UserID: {}", resultDTO.getId());
return resultDTO;
}

@Override
public UserDTO findUserById(Integer id) {
String cacheKey = CACHE_KEY_PREFIX + id;

// 1. 缓存旁路模式:先斩后奏,优先拦截 Redis 缓存
Object cachedValue = redisTemplate.opsForValue().get(cacheKey);
if (cachedValue != null) {
log.info("命中 Redis 缓存,直接返回用户数据,UserID: {}", id);
if (cachedValue instanceof java.util.Map) {
return RedisConfig.OBJECT_MAPPER.convertValue(cachedValue, UserDTO.class);
}
return (UserDTO) cachedValue;
}

// 2. 缓存未命中,穿透到 MySQL 数据库查询
log.warn("❄️ 缓存未命中,开始下沉穿透查询数据库,UserID: {}", id);
User user = userMapper.selectById(id);

if (user == null) {
throw new BizException(ResultCode.USER_NOT_EXIST);
}

// 3. 将 Entity 转化为 DTO,并顺手回填 Redis 缓存,方便下一次直接命中
UserDTO resultDTO = userConvert.toDTO(user);
redisTemplate.opsForValue().set(cacheKey, resultDTO, CACHE_TTL, TimeUnit.MINUTES);
return resultDTO;
}
}


controller

GlobalExceptionHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
package com.demo.user.controller.ex;

import com.demo.user.model.common.Result;
import com.demo.user.model.common.ResultCode;
import com.demo.user.model.ex.BizException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

/**
* 1. 拦截可控的「已知业务自定义异常」
* 生产规范:属于低等级警告,只需打印 warn 日志,不需要打印令人窒息的巨长堆栈。
*/
@ExceptionHandler(BizException.class)
@ResponseStatus(HttpStatus.OK) // 业务异常在 HTTP 层面依然返回 200,由包装体内的 code 决定具体逻辑
public Result<Void> handleBizException(BizException e) {
String traceId = generateTraceId();
log.warn("[BizException] 业务链阻断 -> TraceID: {}, Code: {}, Message: {}", traceId, e.getCode(), e.getMessage());
return Result.fail(e.getCode(), e.getMessage(), traceId);
}

/**
* 2. 拦截「JSR-383 参数校验异常」
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST) // 400 错误
public Result<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
String traceId = generateTraceId();
Map<String, String> errors = new HashMap<>();

ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});

log.warn("[ValidationException] 参数拦截 -> TraceID: {}, Details: {}", traceId, errors);
return Result.fail(ResultCode.PARAM_ERROR.getCode(), ResultCode.PARAM_ERROR.getMessage() + ": " + errors, traceId);
}

/**
* 3. 专门拦截并优雅处理 Spring Boot 3 的 404 路由/静态资源未找到异常
* 生产规范:不需要打印严重堆栈,告知前端路径错误即可。放行 Swagger 内部重定向资源,避免误杀组件!
*/
@ExceptionHandler(NoResourceFoundException.class)
public Result<Void> handleNoResourceFoundException(NoResourceFoundException e) throws NoResourceFoundException {
String resourcePath = e.getResourcePath();

// 如果包含 Swagger 或 Knife4j 的核心静态资源关键字,直接向外抛出,让 Spring 自身的静态资源配置去接管
if (resourcePath != null && (resourcePath.contains("swagger-ui") || resourcePath.contains("api-docs") || resourcePath.contains("knife4j"))) {
throw e;
}
if ("favicon.ico".equals(resourcePath)) {
return Result.success(null);
}

// 优先从本地线程上下文或全局追踪器获取已有的 TraceID
String traceId = org.slf4j.MDC.get("traceId");
if (traceId == null || traceId.isEmpty()) {
traceId = generateTraceId();
}
log.warn("[404NotFound] 用户访问了不存在的资源或路由 -> TraceID: {}, ResourcePath: {}", traceId, resourcePath);
return Result.fail(ResultCode.NOT_FOUND.getCode(), "请求的接口路径或静态资源不存在: " + resourcePath, traceId);
}

/**
* 4. 拦截「不可预知的未知系统崩溃异常」(如 数据库断网、空指针 NullPointerException)
* 生产规范:必须打印最详细的 ERROR 日志,附带完整异常堆栈,用于线上紧急排查复盘!
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // 500 严重系统错误
public Result<Void> handleException(Exception e) {
String traceId = generateTraceId();
// 核心:把 e 传给 log.error,这会在日志里打印完整的底层堆栈线索
log.error("[SystemError] 发现未捕获的异常!! TraceID: " + traceId + ",原因: ", e);

// 生产规范:绝对不能把 e.getMessage() 打印给前端(防止暴露内部数据库表名、代码敏感行数等造成黑客攻击)
return Result.fail(ResultCode.SYSTEM_ERROR.getCode(), ResultCode.SYSTEM_ERROR.getMessage(), traceId);
}

// 辅助工具:生成流水追踪号
private String generateTraceId() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
}

UserController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.demo.user.controller;

import com.demo.user.model.common.Result;
import com.demo.user.model.dto.UserDTO;
import com.demo.user.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@Tag(name = "用户管理接口", description = "提供用户的新增和根据 ID 查询功能")
@RestController
@RequestMapping("/api/v1/user")
public class UserController {

@Resource
private UserService userService;

@Operation(summary = "新增用户", description = "数据经过严格参数校验后,同步写入数据库和缓存")
@PostMapping
public Result<UserDTO> addUser(@Validated @RequestBody UserDTO userDTO) {
UserDTO createdUser = userService.addUser(userDTO);
return Result.success(createdUser);
}

@Operation(summary = "根据 ID 查询用户", description = "优先查询 Redis 缓存,未命中则下沉穿透到 MySQL")
@GetMapping("/{id}")
public Result<UserDTO> findUserById(@Parameter(description = "用户主键ID", required = true) @PathVariable("id") Integer id) {
UserDTO userDTO = userService.findUserById(id);
return Result.success(userDTO);
}
}